Repository navigation
feat(api): MCP OAuth authorization server, RFC-0044 phase 2 (#144) - #176
Merged
Merged
Conversation
Run the deployment's own OAuth 2.1 authorization server so remote MCP clients can authorize against the Studio's accounts: - Better Auth family 1.7.17 -> 1.7.18, plus @better-auth/mcp and @better-auth/oauth-provider 1.7.18 and jose 6.2.12 (the copy better-auth resolves); the pins test guards both. - mcp() + jwt() in createInstance when AuthOptions.mcpOauth is set: code + PKCE (S256) and refresh grants, Ed25519 JWT access tokens (1 h, aud = /mcp, iss = <origin>/api/auth), refresh rotation with a 30 s reuse window, 5 min codes. Better Auth logs go through AuthOptions.log; a wrong API key no longer logs an error. - Migration 0031_oauth: the plugins' DDL from compileMigrations() on 1.7.18 and Cloudhal's agent_grants table. - /mcp verifies the token locally (signature, iss, aud, exp, typ) and reads the grant row: the same Principal a key produces, narrowed by the grant and the member's role; revoked grants and removed members get the invalid_token challenge. - Consent on Studio's /oauth/consent screen through /api/agent-grants/consent: scopes narrowed by the installation role, mode capped, grant + agent_owner + audit in one batch. The provider's own consent, client CRUD and other non-flow endpoints are fenced. - /api/agent-grants list and revoke; AS metadata at the path-inserted and root well-known URLs; the admin Worker and `cloudhal studio --base-url` turn it on, the playground stays out. - Shared suite on better-sqlite3 and Miniflare D1, a full flow on workerd, ADR-0045, ADR-0004/0010/0011 updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
Security review of RFC-0044 phase 2 (PR #176), nine items: - Access tokens carry `cloudhal_grant` (a provider extension) and /mcp accepts a token only for the live grant it names, so tokens issued before a revoke stay dead after the client is connected again. - Revoking a connection or removing a member deletes the provider's stored consent and revokes its refresh tokens in the same SQL batch as the grant change; before /oauth2/authorize, a stored consent with no live grant, or one the member's current role would widen, is forgotten so Studio's consent screen shows (no step-up loop after a promotion). The grant is written before the provider's consent call. - agentCredential() in agent-runtime: an OAuth connection is an agent, never the person behind it. set_embargo through a token waits for a person instead of recording the person's approval; elevations stay key-only. - The consent forward keeps the trusted client-address header, so the rate limit counts per client. - No DPoP in v1: proofs refused at /oauth2/token, not advertised, and tokens with `cnf` refused on /mcp. - Refresh token families end 90 days after the authentication they began with (the provider's token hash is now Cloudhal's too). - The consent screen starts a step-up from the existing grant and marks new scopes; its wording no longer promises Phase 3 features. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
Second verification round of RFC-0044 phase 2 (PR #176): - A refresh token is refused when its family (every refresh token of one authorization code) began before the live grant, and 90 days after the family's first token; the cap no longer follows the Studio session's sliding authTime. - Ending a connection also deletes the member's pending authorization codes for the client. - Member removal revokes grants and the provider side in the same batch as the membership delete, guarded on it. - The authorize check reads a POST authorize's form body. - The provider's issuer-appended metadata (/api/auth/.well-known/*), which listed DPoP, is fenced; the DPoP test asserts Cloudhal's own refusal. - agentCredential() for the auto release key; giveConsent comment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
Third verification round of RFC-0044 phase 2 (PR #176): - The stale-consent check reads a POST body only for an HTTP POST /oauth2/authorize (authorizeSettings.isAuthorize), as the provider does; after sign-in it reads the resumed request's query again (regression from b5968e1). - Before a code exchange, the code must belong to a live grant of its member and client and be no older than it. - A refresh token without a family (no authorizationCodeId) is refused. - Migration 0031 (unreleased) gains the composite index oauthRefreshToken (authorizationCodeId, createdAt) for the family lookup. - Tests: sign-in after a stale consent, the code race, the closed fallback, a step-up keeping the first refresh token, pending codes removed with the member, 415 for a JSON authorize body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
CodeQL js/biased-cryptographic-random on newGrantId: mapping a random byte onto 62 characters with b % 62 made the first eight characters 5/4 as likely. Bytes at or above 248 are now dropped; a test checks the length, the alphabet and an even spread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Madraka <8898640+Madraka@users.noreply.github.com>
This was referenced Nov 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
RFC-0044 Phase 2: the deployment runs its own OAuth 2.1 authorization server, so the discovery documents from #152 now point at something that works. Refs #144. Registration (CIMD, DCR), "Connected agents",
McpClientSetup, the guides and the e2e test stay in Phase 3.Scope → code and tests
pnpm-workspace.yaml:better-auth,@better-auth/core,@better-auth/api-key,@better-auth/kysely-adapter(was 1.7.12) → 1.7.18;@better-auth/utils0.5.1 andbetter-call1.4.5 are already what 1.7.18 needs. New entries@better-auth/mcp1.7.18,@better-auth/oauth-provider1.7.18,jose6.2.12packages/api/test/better-auth-pins.test.ts: plugins on better-auth's version;joseequal to the copy better-auth resolvesmcp()+jwt()increateInstance(§2)packages/api/src/auth/oauth.ts(oauthPlugins, lifetimes,mcpOauthFor,betterAuthLogger),auth/instance.tspackages/cli/test/cloudflare-deploy.test.tsaud=/mcp,iss= issueroauth.tsconstants;auth/access-token.tsverifies signature (EdDSA only),iss,aud,exp,typ: at+jwtwithjoseagainst thejwkstable read through the instance, cached per isolate bykidloggerthrough Cloudhal's logging (#159 sweep)betterAuthLogger: drops the expected "Failed to validate API key", forwards the rest toAuthOptions.log(admin Worker: itslog)0031_oauth(§7)packages/storage/migrations/0031_oauth.ts: DDL generated bygetMigrations(auth.options).compileMigrations()on better-auth 1.7.18 (named in the comment; nothing in existing tables changes) +agent_grantsand its index exactly as §7; registered inmigrations/index.tspackages/storage/test/migrate.test.ts: full lists, a dedicated 0031 test on better-sqlite3 and D1 (Miniflare),do-sqlite.test.tsapplies every migrationagent_grantsand token →Principal(§5)auth/grants.ts(GrantStore,agentIdForGrant,last_used_atat most every 5 min),auth/token-principal.ts,auth/principal.ts(aBearerJWT withtyp: at+jwtnever reaches the key lookup)agent_ownerrow, consent narrowed by the installation role (#173 relies on it)auth/agent-consent.ts(giveConsent:narrow(requested, roleScopes(installationRole)), mode capped like a key, grant +agent_owner+connect_agentaudit row in one batch)content:write→content:draft:own,site:publishrefused, no step-up challenge)apps/studio/src/routes/OAuthConsentPage.tsx(/oauth/consentin the account frame: client, identifying host, redirect host, loopback warning, step-up note, scopes as checkboxes narrowed by role with untickable ones,ModePickercapped by role, Approve/Deny);LoginPageforwards the signed request (oauth_query); templateconsentinscripts/design-lint.ts, DESIGN.md §2ai; lazy namespaceoauthConsent(English by hand, 18 machine drafts viapnpm i18n:export/i18n:import, AI drafts awaiting review)apps/studio/test/oauth-consent.test.tsx,test/login-setup.test.ts;pnpm design:lint/api/agent-grantslist and revoke,ROUTE_SCOPES, playground deny listpackages/api/src/oauth-server.ts(GET /,?all=1withagents:manage,DELETE /:id,GET/POST /consent),scopes.ts(member),playground.ts(RFC-0036 deny, plus both AS metadata paths)test/route-scopes.test.ts,test/playground.test.tsApiDeps.mcp.oauth(§10)adminAuth()with the request's origin;cloudhal studio:--base-url/CLOUDHAL_BASE_URL, otherwise OAuth off with the log line "OAuth for remote MCP clients is off: no public base URL …"; playground untouched (Decision 10)packages/cli/test/cloudflare-deploy.test.ts(full flow on workerd + D1)packages/api/src/testing-mcp-oauth-server.ts, harnesstesting-oauth-harness.ts, run bytest/mcp-oauth-server.test.tsplainrefused); sign-in from the authorization request; tampered request refused; token on/mcp; refresh rotation and reuse window; revoked grant 401; removed member 401; wrongaud/ wrongiss401; step-up 403 then a new consent; consent memory; installation-role narrowing; deny; fenced provider endpointsdocs/adr/0045-mcp-authorization-server.md) linking RFC-0044; ADR indexDeviations from the RFC (and why)
authClient.oauth2.consent. Studio posts toPOST /api/agent-grants/consent, which narrows, caps the mode, calls the provider's/oauth2/consentserver-side and then writes grant +agent_owner+ audit. The provider only accepts consent scopes that are a subset of the request, socontent:write→content:write:owncannot be expressed there; the token therefore carries the requested scopes the member kept and the grant row is the cap (principal =narrow(narrow(token.scope, grant.scopes), roleScopes(role)), §5 wroteintersect, which would drop the:ownvariant). The provider's own/oauth2/consentis fenced increateApiand refuses any HTTP caller without a per-instance server key./oauth2/authorize,/oauth2/token,/oauth2/revokeand/jwksare reachable over HTTP. Client and consent CRUD, introspection, userinfo, registration (Phase 3) and thejwt()plugin's session-JWT/api/auth/tokenanswer 404;jwt({ disableSettingJwtHeader: true }).clientPrivilegesadmits only the server's own calls.OauthServer.createClient(server-side only, no route); the workerd test writes the client row the way the provider stores it./api/agent-grantsships list, revoke and consent only;PATCH :id(mode) and the elevation endpoints come with "Connected agents" in Phase 3. A new consent for the same client replaces scopes and mode and ends a stored elevation.josepinned to 6.2.12, not the newest 6.2.15: better-auth's ownjoseresolves to 6.2.12, and a secondjoseproduced a second@better-auth/corewhose types no longer matched. The pins test now guards this.@better-auth/cimdnot added (CIMD is Phase 3).formtemplate that the design lint does not have; the screen registers a newconsenttemplate (PageLayout + SectionCards + Checkbox.Group + ModePicker + Button) and sits in the account frame.CLOUDHAL_MCP_OAUTH=offswitch on both runtimes (not in the RFC), and plain HTTP is accepted only on loopback (the plugin refuses a plain-HTTP resource elsewhere).patchper the release instruction (the RFC saidminor), for@cloudhal/storage,@cloudhal/api,@cloudhal/cloudflare,@cloudhal/cli(fixed group).mcp()seeds itsoauthResourcerow at init (one indexed read per Better Auth instance, so per request on Workers).testing-mcp-oauth.ts/test/mcp-oauth.test.ts; those keep Phase 1's discovery cases, and Phase 2's cases live next to them intesting-mcp-oauth-server.ts(+ harness) /test/mcp-oauth-server.test.ts, to stay under the size ratchet.design:shotsnaming: the script named PNGs after the whole route, query included, so a signed consent URL overflowed the file name (ENAMETOOLONG); it now names files by the path only (apps/studio/scripts/screenshots.ts, one line).mcp-oauth.ts/mcp-mount.tsare unchanged; the plugin's own PRM handler is never reached (Hono only forwards/api/auth/*).Decisions the RFC did not settle (conservative choice taken)
cloudflare-deploy.test.tschecks the headeralg: EdDSA, typ: at+jwt).rate_limits).baseURLalready is); tokens from*.workers.devdo not work on a custom domain and vice versa.Checks run locally
All one package at a time (
turbo run test --concurrency=1), D1 suites included (Miniflare ran fine locally, nothing left to CI):@cloudhal/storage✅ (migrate tests on better-sqlite3, D1, do-sqlite)@cloudhal/api✅ 41 files / 509 tests, incl. the new OAuth suite (30 cases: 15 × better-sqlite3, 15 × D1)@cloudhal/cloudflare✅@cloudhal/cli✅ 61 files / 443 tests, incl. the new workerd OAuth flow incloudflare-deploy.test.ts(Ed25519 JWT signed in workerd, D1, consent,/mcpvia the SDK client)@cloudhal/studio✅ 81 files / 1305 tests (newoauth-consent.test.tsxincl. the Auto-mode consent rule,login-setup.test.ts, i18n tests afterpnpm i18n:import)pnpm lint0 errors (warnings are the existing backlog;resolvePrincipal's pre-existing complexity warning went 31 → 33),pnpm size:check✅ (baseline tightened forcreateApiandcreateAdminWorker),pnpm design:lint✅ 402 files,tsc --noEmit✅ in api, cli, cloudflare, storage, studiopnpm design:shotsof/oauth/consentwith a real signed request against a throwawaycloudhal studio --base-url http://localhost:8792at 1440 and 390 px: no sideways scroll, no console errors (screens checked by eye)Translations: the 18 non-English
oauthConsent.<locale>.tsfiles and the two new core keys are machine drafts awaiting review;reviewedis unchanged inlocales.ts.Review round 1 fixes (security review of #176)
Every item has a suite test that fails on the first commit of this PR (checked by running the new tests against the old production code).
cloudhal_grant(a provider extension reads the live grant at issuance,auth/oauth.ts); the verifier requires it andtokenPrincipalaccepts the token only when it names the live grant. A reconnect creates a new grant id.testing-mcp-oauth-hardening.ts"keeps a token issued before a revoke dead…";test/access-token.test.ts(no claim → refused)providerRevocation()(auth/grants.ts): plain SQL that deletesoauthConsentand revokesoauthRefreshToken, for one client or every client of the member, in the same batch as the grant change (Studio revoke andStaffStore.revokeGrantsOfon removal), so it can never half-apply. Belt and braces: before/oauth2/authorize, Cloudhal deletes the provider's stored consent when no live grant exists (auth/oauth-hooks.ts), so the screen always shows.invalid_grant; re-invite → consent screen)set_embargothrough a token recorded a person's approvalagentCredential()in agent-runtime (credentials.ts: key, grant, or none).directApproval(moved tomcp-server/src/gate-approval.ts) gives a key itsautoapproval and a connection none: its embargo on live content is saved but waits for a person (status: "with-next-publish").request_elevationuses the helper too.mcp-server/test/schedules.test.ts"set_embargo through an OAuth connection never records the person's approval";agent-runtime/test/credentials.test.tscf-connecting-ipon Workers,CLIENT_ADDRESS_HEADERon Node;addressHeaderinauth/oauth-deps.ts).rateLimitrow is `203.0.113.9/oauth2/tokenrefuses aDPoPproof (invalid_dpop_proof), the AS metadata no longer listsdpop_signing_alg_values_supported, the verifier refuses any token withcnf, and a JWT under theDPoPscheme goes to the token path (refused) instead of the key lookup.test/access-token.test.tsstoreTokens.hash, base64url SHA-256), and before/oauth2/tokena refresh whose family began (authTime) more than 90 days ago is refused withinvalid_grant. ADR-0045 Decision 2 now says 30 d sliding, 90 d absolute.useScopeLabels).apps/studio/test/oauth-consent.test.tsx"starts a step-up from what the connection may do today…"pnpm i18n:export/i18n:import(18 drafts updated, still AI drafts).keyIdaudit (item 4)mcp-server/src/gate-approval.tsdirectApproval(was schedule-tools.ts:526)autoapproval of a reductionmcp-server/src/schedules.ts:332autoReleaseVerdictauto-mode releases!principal.keyId), the release waits for a person; unchangedmcp-server/src/schedule-tools.ts:309autoapproval after a passing verdictmcp-server/src/schedules.ts:736resolveRun,schedule-tools.ts:573automationKey,:1426,:1559automationId, which only automation keys carry; a token is refused or takes the checked "another automation" pathmcp-server/src/elevation-tools.ts:83(and:311after it)agentCredential(); connection elevations are Phase 3api/src/governance.ts:229writerOf,api/src/own.ts:40editsLiveCorrectionsactor.kind === "human"first; a token is an agentpersonApprovalatschedule-tools.ts:289,:331,:928,:1182,:1296ctx.approval) or for non-agents; identical for keys and tokensapi/src/routes/{agent-keys,notifications,schedules}.ts,auth/*Deviation from the review's suggested fixes
content:writefrom a Contributor's token, and the token must still carry a scope that narrows to the grant'scontent:draft:own, so the Contributor's agent would lose its draft rights. Instead the provider's consent memory is checked against the grant and the member's current role before every authorize, which forces the screen exactly when the grant would change.Checks for this round
One package at a time, D1 included (nothing left to CI):
@cloudhal/api✅ 42 files / 523 tests, the OAuth suites 42/42 (21 × better-sqlite3, 21 × Miniflare D1),test/access-token.test.ts@cloudhal/agent-runtime✅ (newcredentials.test.ts),@cloudhal/mcp-server✅ (new embargo case)@cloudhal/studio✅ (new step-up case; i18n tests afterpnpm i18n:import)packages/cli/test/cloudflare-deploy.test.ts✅ (the full flow on workerd still passes with the grant claim and the token hash)pnpm lint0 errors,pnpm size:check✅ (baseline tightened forschedule-tools.ts),pnpm design:lint✅, typecheck ✅ in api, cli, cloudflare, mcp-server, agent-runtime, studioReview round 2 fixes (verification of 632cf45)
Each new test fails on 632cf45 and passes now (run against the old production files).
testing-mcp-oauth-hardening.ts)/oauth2/token, a refresh token is refused when its family began before the live grant'screatedAt(or there is no live grant). A family is every refresh token of one authorization code (authorizationCodeId, kept through rotation); it begins with the first. Comparison at whole seconds, since the provider stamps refresh rows that way.providerRevocationalso deletes the member's pending authorization codes for the client (verificationrows whose JSON value is anauthorization_code).StaffStore.removeputs the grant revoke and the provider cleanup (consent, refresh tokens, codes) in the same batch as the membership delete, each statement guarded on the membership being gone. Sessions, keys, invites and requests still follow; none of them grants anything without a membership (I1, I5).POST /oauth2/authorizeskipped the consent check/api/auth/.well-known/oauth-authorization-server/api/auth/.well-known/*) are fenced (404); clients use the RFC 8414 documents at the root. The DPoP test now asserts Cloudhal's own error (invalid_dpop_proofwithDPOP_REFUSED), so it exercisescheckTokenRequest. ADR-0045: "a request with a DPoP proof gets a 400 rather than a fallback to Bearer".authTime.Also: the
giveConsentcomment now says what a failed provider call leaves on a step-up of a live grant (the approved scopes and mode apply at once, capped by the tokens' ownscopeclaims), andschedule-tools.ts:310takes the key throughagentCredential()(keyOf,gate-approval.ts).Phase 3 (not in this PR):
set_embargothrough an OAuth connection saves the embargo but does not file an approval request; it answerswith-next-publish. Filing a request (or a grant-boundautoapproval, which needs a contract change since theautoapproval names akeyId) belongs with "Connected agents" in Phase 3. I didn't post this on issue #144 myself, because posting there was outside what I could do from here. The text is in the session notes for the project manager to post.Checks for round 2
test/mcp-oauth-server.test.ts54/54 on better-sqlite3 and Miniflare D1.@cloudhal/api: in the full run, 25 D1 cases inapi,assets-api,collections-apiandrolesfailed with Miniflare'sfetch failed(the local D1 process dropping connections under load). Each of those four files passes on its own (roles 46/46, api 12/12, assets 22/22, collections 12/12); the other 510 passed in the run. Please let CI's run confirm the D1 halves.@cloudhal/mcp-server✅;packages/cli/test/cloudflare-deploy.test.ts✅ (workerd flow).pnpm lint0 errors,pnpm size:check✅,pnpm design:lint✅, typecheck ✅ in api, mcp-server, cli, cloudflare.Review round 3 fixes (verification of b5968e1)
testing-mcp-oauth-hardening.ts)method === "POST"andauthorizeSettings.isAuthorize === true(absentauthorizeSettingsmeans an HTTP authorize); a resumed authorize (after sign-in or consent) reads the query.giveConsent's comment updated.familyStartscanned the whole familyoauthRefreshToken_family_idx ("authorizationCodeId", "createdAt")added to the unreleased migration 0031 (Cloudhal's, below the generated DDL).packages/storage/test/migrate.test.tschecks the index on better-sqlite3 and D1authorizationCodeId, no known start: the refresh is refused (invalid_grant)./oauth2/tokenwithauthorization_code, the code's verification row (looked up by the hashed code) must belong to a live grant of its member and client and be no older than the grant (whole seconds).created_atCASE); the removal-batch test now creates a pending code and asserts it is deleted; a JSON-bodyPOST /oauth2/authorizegets 415; the 90-day test is renamed after the family-start anchor.Checks for round 3
test/mcp-oauth-server.test.ts(64 cases, sqlite and D1),packages/storage/test/migrate.test.ts+do-sqlite.test.ts✅. Each new case was run against the production files of b5968e1 (the sign-in case also against 632cf45, where it passes).@cloudhal/storage✅.@cloudhal/api: 528 passed in the full run; 17 D1 cases inassets-api,fields-api,rate-limitsandrolesfailed with Miniflare'sfetch failed(the local D1 process dropping connections under load, different files than last round). Each of those files passes on its own (22/22, 12/12, 4/4, 46/46). D1 halves left to CI.@cloudhal/mcp-server✅,pnpm lint0 errors,pnpm size:check✅,pnpm design:lint✅, typecheck ✅.🤖 Generated with Claude Code